import { StringUtils, UIDLUtils } from '@teleporthq/teleport-shared'
import {
  StyleUtils,
  StyleBuilders,
  HASTUtils,
  ASTUtils,
  createBinaryExpression,
} from '@teleporthq/teleport-plugin-common'
import * as types from '@babel/types'
import {
  ComponentPluginFactory,
  ComponentPlugin,
  UIDLDynamicReference,
  UIDLStyleDefinitions,
  ChunkType,
  FileType,
  HastNode,
  UIDLElementNodeReferenceStyles,
  UIDLStyleMediaQueryScreenSizeCondition,
  PluginCSS,
  UIDLElement,
  UIDLElementNode,
  UIDLExpressionValue,
  StateDefaultValueTypes,
  PropDefaultValueTypes,
} from '@teleporthq/teleport-types'
import { createStyleSheetPlugin } from './style-sheet'
import { createConditionalStatement } from './utils'

interface CSSPluginConfig {
  chunkName: string
  templateChunkName: string
  componentDecoratorChunkName: string
  inlineStyleAttributeKey: string // style vs :style vs ...
  classAttributeName: string // class vs className
  templateStyle: 'html' | 'jsx'
  declareDependency: 'import' | 'decorator' | 'none'
  dynamicVariantPrefix?: string
  staticPropReferences?: boolean
}

const createCSSPlugin: ComponentPluginFactory<CSSPluginConfig> = (config) => {
  const {
    chunkName = 'style-chunk',
    templateChunkName = 'template-chunk',
    componentDecoratorChunkName = 'component-decorator',
    inlineStyleAttributeKey = 'style',
    classAttributeName = 'class',
    templateStyle = 'html',
    declareDependency = 'none',
    dynamicVariantPrefix,
    staticPropReferences = false,
  } = config || {}

  const cssPlugin: ComponentPlugin = async (structure) => {
    const { uidl, chunks, dependencies, options } = structure
    const {
      node,
      styleSetDefinitions: componentStyleSet = {},
      propDefinitions = {},
      stateDefinitions = {},
    } = uidl
    const { projectStyleSet, designLanguage: { tokens = {} } = {}, isRootComponent } = options || {}
    const {
      styleSetDefinitions = {},
      fileName: projectStyleSheetName,
      path: projectStyleSheetPath,
    } = projectStyleSet || {}

    if (isRootComponent) {
      if (Object.keys(tokens).length > 0 || Object.keys(styleSetDefinitions).length > 0) {
        dependencies[projectStyleSheetName] = {
          type: 'local',
          path: `${projectStyleSheetPath}${projectStyleSheetName}.${FileType.CSS}`,
          meta: {
            importJustPath: true,
          },
        }
      }

      return structure
    }

    const templateChunk = chunks.find((chunk) => chunk.name === templateChunkName)
    const componentDecoratorChunk = chunks.find(
      (chunk) => chunk.name === componentDecoratorChunkName
    )

    const jsxNodesLookup = templateChunk.meta.nodesLookup as Record<
      string,
      HastNode | types.JSXElement
    >

    const propsPrefix: string = templateChunk.meta.dynamicRefPrefix
      ? ((templateChunk.meta.dynamicRefPrefix as Record<string, unknown>).prop as string)
      : ('' as string)

    const cssMap: string[] = []
    const mediaStylesMap: Record<
      string,
      Array<{ [x: string]: Record<string, string | number> }>
    > = {}

    const generateStylesForElementNode = (element: UIDLElement) => {
      const classNamesToAppend: Set<string> = new Set()
      const dynamicVariantsToAppend: Set<string | types.ConditionalExpression> = new Set()
      const {
        style = {},
        key,
        referencedStyles = {},
        attrs = {},
        elementType,
        dependency,
      } = element

      const root = jsxNodesLookup[key]
      if (!root) {
        return
      }

      // Refer to line 323 all component scoped styles are appended with component name by default
      if (dependency?.type === 'local') {
        StyleBuilders.setPropValueForCompStyle({
          attrs,
          root,
          templateStyle,
          // elementType is used here in-order to target the component name that the class is actually defined.
          // Here we are appendigng to the node where the component is being called.
          getClassName: (styleName: string) =>
            StringUtils.camelCaseToDashCase(elementType + styleName),
        })
      }

      if (
        Object.keys(style).length === 0 &&
        Object.keys(referencedStyles).length === 0 &&
        Object.keys(componentStyleSet).length === 0
      ) {
        return
      }

      const className = StringUtils.camelCaseToDashCase(key)

      const { staticStyles, dynamicStyles, tokenStyles } =
        UIDLUtils.splitDynamicAndStaticStyles(style)

      if (Object.keys(staticStyles).length > 0 || Object.keys(tokenStyles).length > 0) {
        const collectedStyles = {
          ...StyleUtils.getContentOfStyleObject(staticStyles),
          ...StyleUtils.getCSSVariablesContentFromTokenStyles(tokenStyles),
        } as Record<string, string | number>

        cssMap.push(StyleBuilders.createCSSClass(className, collectedStyles))
        classNamesToAppend.add(className)
      }

      if (Object.keys(dynamicStyles).length > 0) {
        /* If dynamic styles are on nested-styles they are unfortunately lost,
          since inline style does not support that */
        if (templateStyle === 'html') {
          const inlineStyles = createDynamicInlineStyle(dynamicStyles)
          HASTUtils.addAttributeToNode(
            root as HastNode,
            inlineStyleAttributeKey,
            `{${inlineStyles}}`
          )
        } else {
          const inlineStyles = UIDLUtils.transformDynamicStyles(dynamicStyles, (styleValue) =>
            StyleBuilders.createDynamicStyleExpression(styleValue, propsPrefix)
          )
          ASTUtils.addAttributeToJSXTag(
            root as types.JSXElement,
            inlineStyleAttributeKey,
            inlineStyles
          )
        }
      }

      Object.values(referencedStyles).forEach((styleRef: UIDLElementNodeReferenceStyles) => {
        switch (styleRef.content.mapType) {
          case 'inlined': {
            const filtredStyles = UIDLUtils.splitDynamicAndStaticStyles(styleRef.content.styles)
            const collectedStyles = {
              ...StyleUtils.getContentOfStyleObject(filtredStyles.staticStyles),
              ...StyleUtils.getCSSVariablesContentFromTokenStyles(filtredStyles.tokenStyles),
            } as Record<string, string | number>

            const condition = styleRef.content.conditions[0]
            const { conditionType } = condition
            if (conditionType === 'screen-size') {
              const { maxWidth } = condition as UIDLStyleMediaQueryScreenSizeCondition
              if (!mediaStylesMap[String(maxWidth)]) {
                mediaStylesMap[String(maxWidth)] = []
              }
              mediaStylesMap[String(maxWidth)].push({ [className]: collectedStyles })
            }

            if (condition.conditionType === 'element-state') {
              cssMap.push(
                StyleBuilders.createCSSClassWithSelector(
                  className,
                  `&:${condition.content}`,
                  collectedStyles
                )
              )
            }

            classNamesToAppend.add(className)
            return
          }

          case 'component-referenced': {
            if (styleRef.content.content.type === 'static') {
              classNamesToAppend.add(String(styleRef.content.content.content))
            }

            if (
              styleRef.content.content.type === 'dynamic' &&
              styleRef.content.content.content.referenceType === 'prop'
            ) {
              const defaultPropValue =
                propDefinitions[styleRef.content.content.content.id]?.defaultValue

              if (defaultPropValue) {
                propDefinitions[styleRef.content.content.content.id].defaultValue =
                  StringUtils.camelCaseToDashCase(String(defaultPropValue))
              }

              // staticPropReferences flag is only used for just html-code generation.
              // This is used to append the class name to the node where the component is being called.
              // Instead of how frameworks handle them using props at runtime. This makes the props
              // to behave statically during the generation time instead by appending them directly.
              if (staticPropReferences) {
                if (defaultPropValue === undefined || typeof defaultPropValue !== 'string') {
                  return
                }
                classNamesToAppend.add(
                  StringUtils.camelCaseToDashCase(uidl.name + defaultPropValue)
                )
              } else {
                dynamicVariantsToAppend.add(styleRef.content.content.content.id)
              }
            }

            if (
              styleRef.content.content.type === 'dynamic' &&
              styleRef.content.content.content.referenceType === 'comp'
            ) {
              if (!componentStyleSet[styleRef.content.content.content.id]) {
                throw new PluginCSS(
                  `Node ${elementType} is referring to a comp style instance ${styleRef.content.content.content.id} which is missing.`
                )
              }
              classNamesToAppend.add(
                StringUtils.camelCaseToDashCase(uidl.name + styleRef.content.content.content.id)
              )
            }

            return
          }

          case 'project-referenced': {
            const { content } = styleRef
            const referedStyle = styleSetDefinitions[content.referenceId]
            if (!referedStyle) {
              throw new PluginCSS(
                `Style used from global stylesheet is missing - ${content.referenceId}`
              )
            }

            if (styleRef.content.condition) {
              if (templateStyle === 'html') {
                const {
                  value: staticValue,
                  reference,
                  expression: { conditions, matchingCriteria },
                } = styleRef.content.condition

                const {
                  content: { referenceType, id, refPath = [] },
                } = reference

                switch (referenceType) {
                  case 'prop': {
                    const usedProp = propDefinitions[id]
                    if (usedProp === undefined || usedProp.defaultValue === undefined) {
                      throw new PluginCSS(`Prop with ${id} is missing in the propDefinitions.`)
                    }

                    let defaultValue = usedProp.defaultValue
                    let operandDefaultValue: StateDefaultValueTypes | PropDefaultValueTypes | string
                    for (const path of refPath) {
                      defaultValue = (defaultValue as Record<string, unknown[]>)?.[path]
                    }

                    // If defaultValue is undefined or null after path traversal, use original default
                    defaultValue = defaultValue ?? usedProp.defaultValue

                    // our conditions can be of type 'expr' or they can be a dynamic reference
                    const rightSideCondition = conditions[0].operand
                    const rightSideConditionType = typeof rightSideCondition
                    if (rightSideConditionType === 'object') {
                      const type = (
                        rightSideCondition as UIDLDynamicReference | UIDLExpressionValue
                      ).type
                      if (type === 'dynamic') {
                        const dynamicRef = rightSideCondition as UIDLDynamicReference
                        if (dynamicRef.content.referenceType === 'prop') {
                          const prop = propDefinitions[dynamicRef.content.id]
                          if (prop && prop.defaultValue) {
                            operandDefaultValue = prop.defaultValue
                          }
                        }
                        if (dynamicRef.content.referenceType === 'state') {
                          const state = stateDefinitions[dynamicRef.content.id]
                          if (state && state.defaultValue) {
                            defaultValue = state.defaultValue
                          }
                        }
                      }
                      if (type === 'expr') {
                        operandDefaultValue = (rightSideCondition as UIDLExpressionValue).content
                      }
                    } else {
                      operandDefaultValue = rightSideCondition
                    }

                    // Since we know the operand and the default value from the prop.
                    // We can try building the condition and check if the condition is true or false.
                    // @todo: You can only use a 'value' in UIDL or 'conditions' but not both.
                    // UIDL validations need to be improved on this aspect.
                    const resolvedConditions = [
                      {
                        operation: conditions[0].operation,
                        operand: operandDefaultValue as string,
                      },
                    ]

                    const dynamicConditions = createConditionalStatement(
                      staticValue !== undefined
                        ? [{ operand: staticValue, operation: '===' }]
                        : resolvedConditions,
                      defaultValue
                    )
                    const matchCondition =
                      matchingCriteria && matchingCriteria === 'all' ? '&&' : '||'
                    const conditionString = dynamicConditions.join(` ${matchCondition} `)

                    // tslint:disable-next-line function-constructor
                    const isConditionPassing = new Function(`return ${conditionString}`)()
                    if (isConditionPassing) {
                      classNamesToAppend.add(styleRef.content.referenceId)
                      return
                    }
                    return
                  }
                  case 'state': {
                    const usedState = stateDefinitions[id]
                    if (usedState === undefined || usedState.defaultValue === undefined) {
                      throw new PluginCSS(`State with ${id} is missing in the stateDefinitions.`)
                    }
                    let defaultValue = usedState.defaultValue
                    for (const path of refPath) {
                      defaultValue = (defaultValue as Record<string, unknown[]>)?.[path]
                    }
                    // If defaultValue is undefined or null after path traversal, use original default
                    defaultValue = defaultValue ?? usedState.defaultValue
                    // Since we know the operand and the default value from the state.
                    // We can try building the condition and check if the condition is true or false.

                    const dynamicConditions = createConditionalStatement(
                      staticValue !== undefined
                        ? [{ operand: staticValue, operation: '===' }]
                        : conditions,
                      defaultValue
                    )
                    const matchCondition =
                      matchingCriteria && matchingCriteria === 'all' ? '&&' : '||'
                    const conditionString = dynamicConditions.join(` ${matchCondition} `)
                    // tslint:disable-next-line function-constructor
                    const isConditionPassing = new Function(`return ${conditionString}`)()
                    if (isConditionPassing) {
                      classNamesToAppend.add(styleRef.content.referenceId)
                      return
                    }
                    return
                  }

                  default: {
                    classNamesToAppend.add(styleRef.content.referenceId)
                    return
                  }
                }
              } else {
                const nameToAppend = styleRef.content.condition.reference.content.id

                const { conditions } = styleRef.content.condition.expression

                const operator = conditions[0].operation as '===' | '!==' | '<' | '<=' | '>' | '>='
                const right = conditions[0].operand as string | number | boolean
                const referenceType = styleRef.content.condition.reference.content.referenceType

                let binaryExpressionType = ''
                switch (referenceType) {
                  case 'prop': {
                    binaryExpressionType = propDefinitions[nameToAppend].type
                    break
                  }
                  case 'state': {
                    binaryExpressionType = stateDefinitions[nameToAppend].type
                    break
                  }
                  default: {
                    throw new PluginCSS(
                      `Un-supported reference type ${referenceType} for ${nameToAppend}`
                    )
                  }
                }

                const binaryExpression = createBinaryExpression(
                  { operation: operator, operand: right },
                  {
                    key: (referenceType === 'prop' ? 'props?.' : '') + nameToAppend,
                    type: binaryExpressionType,
                  }
                )

                const conditionalExpression = types.conditionalExpression(
                  binaryExpression,
                  types.stringLiteral(content.referenceId),
                  types.stringLiteral('')
                )

                dynamicVariantsToAppend.add(conditionalExpression)
                return
              }
            } else {
              classNamesToAppend.add(content.referenceId)
            }
            return
          }

          default: {
            throw new PluginCSS(
              `Un-supported style reference ${JSON.stringify(styleRef.content, null, 2)}`
            )
          }
        }
      })

      if (templateStyle === 'html') {
        if (classNamesToAppend.size > 0) {
          HASTUtils.addClassToNode(root as HastNode, Array.from(classNamesToAppend).join(' '))
        }

        if (dynamicVariantsToAppend.size > 1) {
          throw new PluginCSS(`Node ${
            node.content?.name || node.content?.key
          } is using multiple dynamic variants using propDefinitions.
          We can have only one dynamic variant at once`)
        }

        if (dynamicVariantPrefix && dynamicVariantsToAppend.size > 0) {
          HASTUtils.addAttributeToNode(
            root as HastNode,
            dynamicVariantPrefix,
            Array.from(dynamicVariantsToAppend).join(' ')
          )
        }
      } else {
        ASTUtils.addClassStringOnJSXTag(
          root as types.JSXElement,
          Array.from(classNamesToAppend).join(' '),
          classAttributeName,
          Array.from(dynamicVariantsToAppend).map((variant) => {
            const dynamicAttrValueIdentifier: types.Identifier = dynamicVariantPrefix
              ? types.identifier(dynamicVariantPrefix)
              : types.identifier(propsPrefix)

            if (typeof variant === 'string') {
              return types.memberExpression(dynamicAttrValueIdentifier, types.identifier(variant))
            } else {
              // variant is a ConditionalExpression, so return it directly
              return variant
            }
          })
        )
      }
    }

    UIDLUtils.traverseElements(node, generateStylesForElementNode)
    for (const prop of Object.values(propDefinitions)) {
      if (prop.type === 'element' && prop.defaultValue) {
        UIDLUtils.traverseElements(
          prop.defaultValue as UIDLElementNode,
          generateStylesForElementNode
        )
      }
    }

    if (Object.keys(componentStyleSet).length > 0) {
      StyleBuilders.generateStylesFromStyleSetDefinitions(
        componentStyleSet,
        cssMap,
        mediaStylesMap,
        (styleName: string) => {
          return StringUtils.camelCaseToDashCase(uidl.name + styleName)
        }
      )
    }

    if (Object.keys(mediaStylesMap).length > 0) {
      cssMap.push(...StyleBuilders.generateMediaStyle(mediaStylesMap))
    }

    if (cssMap.length > 0) {
      /**
       * Setup an import statement for the styles
       * The name of the file is either in the meta of the component generator
       * or we fallback to the name of the component
       */
      const cssFileName = UIDLUtils.getStyleFileName(uidl)

      if (declareDependency === 'decorator' && componentDecoratorChunk) {
        const decoratorAST = componentDecoratorChunk.content
        // @ts-ignore
        const decoratorParam = decoratorAST.expression.arguments[0]
        ASTUtils.addPropertyToASTObject(decoratorParam, 'styleUrls', [
          `${cssFileName}.${FileType.CSS}`,
        ])
        cssMap.unshift(`:host { \n  display: contents; \n}`)
      }

      if (declareDependency === 'import') {
        dependencies.styles = {
          // styles will not be used in this case as we have importJustPath flag set
          type: 'local',
          path: `./${cssFileName}.${FileType.CSS}`,
          meta: {
            importJustPath: true,
          },
        }
      }

      chunks.push({
        type: ChunkType.STRING,
        name: chunkName,
        fileType: FileType.CSS,
        content: cssMap.join('\n \n'),
        linkAfter: [],
      })
    }

    return structure
  }

  return cssPlugin
}

export { createStyleSheetPlugin, createCSSPlugin }

export default createCSSPlugin()

const createDynamicInlineStyle = (styles: UIDLStyleDefinitions) => {
  return Object.keys(styles)
    .map((styleKey) => {
      return `${styleKey}: ${(styles[styleKey] as UIDLDynamicReference).content.id}`
    })
    .join(', ')
}
